//
// ========================================================================
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
//
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
// ========================================================================
//

package org.eclipse.jetty.deploy;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;

import org.eclipse.jetty.server.Deployable;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.util.Attributes;
import org.eclipse.jetty.util.FileID;
import org.eclipse.jetty.util.Loader;
import org.eclipse.jetty.util.StringUtil;
import org.eclipse.jetty.util.component.Environment;
import org.eclipse.jetty.util.resource.Resource;
import org.eclipse.jetty.util.resource.ResourceFactory;
import org.eclipse.jetty.util.resource.Resources;
import org.eclipse.jetty.xml.XmlConfiguration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Creates a {@link ContextHandler} from a WAR file and/or one or more XML files and property files.
 */
public class StandardContextHandlerFactory implements ContextHandlerFactory
{
    private static final Logger LOG = LoggerFactory.getLogger(StandardContextHandlerFactory.class);

    private static Map<String, String> asProperties(Attributes attributes)
    {
        Map<String, String> props = new HashMap<>();
        attributes.getAttributeNameSet().forEach((name) ->
        {
            Object value = attributes.getAttribute(name);
            props.put(name, Objects.toString(value));
        });
        return props;
    }

    /**
     * Convert an Object into a String suitable for use as a Properties value.
     *
     * @param obj the object to convert
     * @return the String representing the Object, or null if {@code obj} is null.
     */
    private static String asPropertyValue(Object obj)
    {
        if (obj == null)
            return null;
        if (obj instanceof Enum<?> en)
            return en.name();
        if (obj instanceof Environment env)
            return env.getName();
        return Objects.toString(obj);
    }

    @Override
    public ContextHandler newContextHandler(Server server, Environment environment, Path mainPath, Set<Path> otherPaths, Attributes deployAttributes) throws Exception
    {
        // Resolve real file (hopefully eliminating alias issues)
        mainPath = Objects.requireNonNull(mainPath).toRealPath();

        // Can happen if the file existed when notified by scanner (as either an ADD or CHANGE),
        // and then the file was deleted before reaching this code.
        if (!Files.exists(mainPath))
            throw new IllegalStateException("Main path does not exist " + mainPath);

        deployAttributes.setAttribute(Deployable.MAIN_PATH, mainPath);
        deployAttributes.setAttribute(Deployable.OTHER_PATHS, otherPaths);

        if (environment == null)
        {
            String error = String.format("Environment not declared for app [%s].  The available environments are: %s",
                mainPath,
                Environment.getAll().stream()
                    .map(Environment::getName)
                    .collect(Collectors.joining(", ", "[", "]"))
            );
            throw new IllegalStateException(error);
        }

        if (LOG.isDebugEnabled())
            LOG.debug("createContextHandler {} in {}", mainPath, environment.getName());

        ClassLoader old = Thread.currentThread().getContextClassLoader();
        try
        {
            Thread.currentThread().setContextClassLoader(environment.getClassLoader());

            // The process now is to figure out the context object to use.
            // This can come from a number of places.
            // 1. If an XML deployable, this is the <Configure class="contextClass"> entry.
            // 2. If another deployable (like a web archive, or directory), then check attributes.
            //    a. use the app attributes to figure out the context handler class.
            //    b. use the environment attributes default context handler class.
            Object context = newContextInstance(server, environment, mainPath, deployAttributes, mainPath);
            if (context == null)
                throw new IllegalStateException("unable to create ContextHandler for " + mainPath);

            if (LOG.isDebugEnabled())
                LOG.debug("Context {} created from app {}", context.getClass().getName(), mainPath);

            // Apply environment properties and XML to context
            if (applyEnvironmentXml(server, context, environment, deployAttributes))
            {
                // If an XML deployable, apply full XML over environment XML changes
                if (FileID.isXml(mainPath))
                    context = applyXml(server, context, mainPath, environment, deployAttributes);
            }

            // Get the ContextHandler out from the constructed context.
            ContextHandler contextHandler = getContextHandler(context);

            // Copy non-deploy attributes into ContextHandler attributes for context use
            deployAttributes.getAttributeNameSet()
                .forEach(k -> contextHandler.setAttribute(k, deployAttributes.getAttribute(k)));

            return contextHandler;
        }
        finally
        {
            Thread.currentThread().setContextClassLoader(old);
        }
    }

    protected Object applyXml(Server server, Object context, Path xml, Environment environment, Attributes attributes) throws Exception
    {
        if (!FileID.isXml(xml))
            return null;

        try (ResourceFactory.Closeable resourceFactory = ResourceFactory.closeable())
        {
            XmlConfiguration xmlConfiguration = new XmlConfiguration(resourceFactory.newResource(xml), null, asProperties(attributes));
            xmlConfiguration.getIdMap().put("Environment", environment.getName());
            xmlConfiguration.setJettyStandardIdsAndProperties(server, xml);

            // Put all Environment attributes into XmlConfiguration as properties that can be used.
            attributes.getAttributeNameSet()
                .stream()
                .filter(k -> !k.startsWith("jetty.home") &&
                    !k.startsWith("jetty.base") &&
                    !k.startsWith("jetty.webapps"))
                .forEach(k ->
                {
                    Object v = attributes.getAttribute(k);
                    if (v == null)
                        xmlConfiguration.getProperties().remove(k);
                    else
                        xmlConfiguration.getProperties().put(k, asPropertyValue(v));
                });

            // Run configure against appropriate class loader.
            ClassLoader xmlClassLoader = getClassLoader(context, environment);
            ClassLoader oldClassLoader = Thread.currentThread().getContextClassLoader();
            Thread.currentThread().setContextClassLoader(xmlClassLoader);

            try
            {
                // Create or configure the context
                ContextHandler contextHandler = getContextHandler(context);
                if (contextHandler == null)
                    return xmlConfiguration.configure();
                return xmlConfiguration.configure(context);
            }
            finally
            {
                Thread.currentThread().setContextClassLoader(oldClassLoader);
            }
        }
    }

    protected void initializeContextHandler(ContextHandler contextHandler, Path path, Attributes attributes)
    {
        if (LOG.isDebugEnabled())
            LOG.debug("initializeContextHandler {}", contextHandler);

        assert contextHandler != null;

        if (contextHandler.getBaseResource() == null)
        {
            ResourceFactory resourceFactory = ResourceFactory.of(contextHandler);
            Resource resource = resourceFactory.newResource(path);

            // Only set base resource on safe directory paths.
            // Deployable contexts that want to do other things, can use the Deployable.BASE_RESOURCE to figure things out.
            if (Resources.isDirectory(resource))
                contextHandler.setBaseResource(resource);
            else if (attributes.getAttribute(Deployable.BASE_RESOURCE) == null)
                attributes.setAttribute(Deployable.BASE_RESOURCE, resource);
        }

        // copy attributes into context
        attributes.getAttributeNameSet()
            .forEach((key) ->
            {
                Object value = attributes.getAttribute(key);
                if (LOG.isDebugEnabled())
                    LOG.debug("Setting attribute [{}] to [{}] in context {}", key, value, contextHandler);
                contextHandler.setAttribute(key, value);
            });

        String contextPath = (String)attributes.getAttribute(Deployable.CONTEXT_PATH);
        if (StringUtil.isNotBlank(contextPath))
        {
            if (LOG.isDebugEnabled())
                LOG.debug("Context {} initialized with contextPath: {}", contextHandler, contextPath);
            contextHandler.setContextPath(contextPath);
        }
    }

    protected void initializeContextPath(ContextHandler contextHandler, Path path, Attributes attributes)
    {
        if (contextHandler == null)
            return;

        // Strip any 3 char extension from non directories
        String basename = FileID.getBasename(path);
        String contextPath = basename;

        // special case of archive (or dir) named "root" is / context
        if (contextPath.equalsIgnoreCase("root"))
        {
            contextPath = "/";
        }
        // handle root with virtual host form
        else if (StringUtil.asciiStartsWithIgnoreCase(contextPath, "root-"))
        {
            int dash = contextPath.indexOf('-');
            String virtual = contextPath.substring(dash + 1);
            contextHandler.setVirtualHosts(Arrays.asList(virtual.split(",")));
            contextPath = "/";
        }

        // Ensure "/" is Prepended to all context paths.
        if (contextPath.charAt(0) != '/')
            contextPath = "/" + contextPath;

        if (contextHandler.getDisplayName() == null)
            contextHandler.setDisplayName(basename);

        // Set this via initializeDeployable to avoid default-context-path state changes.
        attributes.setAttribute(Deployable.DEFAULT_CONTEXT_PATH, contextPath);
    }

    /**
     * Apply optional environment specific XML to context.
     *
     * @param server the Server instance for referencing in XML
     * @param context the context to apply environment specific behavior to
     * @param environment the environment to use
     * @param attributes the attributes used to deploy the app
     * @return true if environment specific XML was applied.
     * @throws Exception if unable to apply environment configuration.
     */
    private boolean applyEnvironmentXml(Server server, Object context, Environment environment, Attributes attributes) throws Exception
    {
        // Collect the optional environment context xml files.
        // Order them according to the name of their property key names.
        @SuppressWarnings("unchecked")
        List<Path> sortedEnvXmlPaths = (List<Path>)attributes.getAttribute(ContextHandlerFactory.ENVIRONMENT_XML_PATHS_ATTRIBUTE);

        if (sortedEnvXmlPaths == null || sortedEnvXmlPaths.isEmpty())
            // nothing to do here
            return false;

        // apply each context environment xml file
        for (Path envXmlPath : sortedEnvXmlPaths)
        {
            if (LOG.isDebugEnabled())
                LOG.debug("Applying environment specific context file {}", envXmlPath);
            context = applyXml(server, context, envXmlPath, environment, attributes);
        }
        return true;
    }

    private ClassLoader getClassLoader(Object context, Environment environment)
    {
        ContextHandler contextHandler = getContextHandler(context);
        if (contextHandler != null)
        {
            ClassLoader classLoader = contextHandler.getClassLoader();
            if (classLoader != null)
                return classLoader;
        }

        return environment.getClassLoader();
    }

    /**
     * Find the {@link ContextHandler} for the provided {@link Object}
     *
     * @param context the raw context object
     * @return the {@link ContextHandler} for the context, or null if no ContextHandler associated with context.
     */
    private ContextHandler getContextHandler(Object context)
    {
        if (context == null)
            return null;

        if (context instanceof ContextHandler handler)
            return handler;

        if (Supplier.class.isAssignableFrom(context.getClass()))
        {
            @SuppressWarnings("unchecked")
            Supplier<ContextHandler> provider = (Supplier<ContextHandler>)context;
            return provider.get();
        }

        if (LOG.isTraceEnabled())
            LOG.trace("Not a ContextHandler ({}): {}", context.getClass().getName(), context);
        return null;
    }

    private void initializeDeployable(Object context, Attributes attributes)
    {
        // Ensure that WAR fallback String (that WebInfConfiguration needs) is
        // only created once.
        if (attributes.getAttribute(Deployable.WAR) == null)
        {
            Path mainPath = (Path)attributes.getAttribute(Deployable.MAIN_PATH);
            if (FileID.isWebArchive(mainPath))
            {
                // Set a backup value for the path to the war in case it hasn't already been set
                // via a different means.  This is especially important for a deployable app
                // that is only a <name>.war file (no XML).  The eventual WebInfConfiguration
                // will use this attribute.
                attributes.setAttribute(Deployable.WAR, mainPath.toString());
            }
        }

        if (context instanceof Deployable deployable)
            deployable.initializeDefaults(attributes);
    }

    /**
     * Initialize a new Context object instance.
     *
     * <p>
     * The search order is:
     * </p>
     * <ol>
     * <li>If app deployable path is XML, apply XML {@code <Configuration>}.</li>
     * <li>Fallback to environment attribute {@link #DEFAULT_CONTEXT_HANDLER_CLASS_ATTRIBUTE}, and initialize context.</li>
     * </ol>
     *
     * @param environment the environment context applies to
     * @param app the app for the context
     * @param attributes the Attributes used to deploy the app
     * @param path the path of the deployable
     * @return the Context Object.
     * @throws Exception if unable to create Object instance.
     */
    private Object newContextInstance(Server server, Environment environment, Path app, Attributes attributes, Path path) throws Exception
    {
        if (LOG.isDebugEnabled())
            LOG.debug("newContextInstance({}, {}, {}, {})", server, environment, app, path);

        if (FileID.isXml(path))
        {
            // track if context is created from XML or an existing one is just being configured by XML
            Object context = applyXml(server, null, path, environment, attributes);
            ContextHandler contextHandler = getContextHandler(context);
            if (contextHandler == null)
                throw new IllegalStateException("Unknown context type of " + context);

            initializeContextPath(contextHandler, path, attributes);
            initializeContextHandler(contextHandler, path, attributes);
            initializeDeployable(context, attributes);
            return context;
        }

        // fallback to default from environment.
        Object context = newInstance((String)environment.getAttribute(ContextHandlerFactory.DEFAULT_CONTEXT_HANDLER_CLASS_ATTRIBUTE));
        if (context != null)
        {
            ContextHandler contextHandler = getContextHandler(context);
            if (contextHandler == null)
                throw new IllegalStateException("Unknown context type of " + context);

            initializeContextPath(contextHandler, path, attributes);
            initializeContextHandler(contextHandler, path, attributes);
            initializeDeployable(context, attributes);
            return context;
        }

        return null;
    }

    private Object newInstance(String className) throws Exception
    {
        if (StringUtil.isBlank(className))
            return null;
        if (LOG.isDebugEnabled())
            LOG.debug("Attempting to load class {}", className);
        Class<?> clazz = Loader.loadClass(className);
        if (clazz == null)
            return null;
        return clazz.getConstructor().newInstance();
    }
}
